TinyHttpd源码解析

一直很好奇web的工作原理,加之这阵子也在学习Python爬虫,就有想法了解这部分的知识,所以买了一本图解HTTP。
这本书简洁清晰也很形象地介绍了HTTP协议的工作流程,对零基础了解HTTP协议有着不错的引导作用。
书也很薄,可以很快看完。不过纯粹通过看书学习一个协议难免会浮于表面,因此,我找了TinyHttpd的source code来了解http协议的实现和实际工作场景。

1. 背景

一直很好奇web的工作原理,加之这阵子也在学习Python爬虫,就有想法了解这部分的知识,所以买了一本图解HTTP。这本书简洁清晰也很形象地介绍了HTTP协议的工作流程,对零基础了解HTTP协议有着不错的引导作用。书也很薄,可以很快看完。不过纯粹通过看书学习一个协议难免会浮于表面,因此,我找了TinyHttpd的source code来了解http协议的实现和实际工作场景。

2. 源码解析

声明:这篇里面的代码并不是TinyHttpd的源码,是我自己手动临摹一遍的代码,实测跑通了。一直相信代码自己码一遍会比纯看加注释收获多一些。同时,TinyHttpd只有几百行,自己码一遍也不算什么。关于阅读tinyhttpd的source code,个人觉得可以以如下顺序展开:main –> startup –> accept_request –> execute_cgi –>了解cgi实现,因此本文就按照此顺序展开分享。

主体框架 -> main()

main函数是整个httpd的工作框架,具体的实现流程如下, startup创建socket通信并建立端口监听 –> accept等待客户端连接请求 –> accept_request处理客户端http请求 –> cleanup释放资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int main(int argc,char *argv[])
{
int sever_sock = -1;
u_short port = 5277;
int client_sock = -1;
struct sockaddr_in client_name;
unsigned int client_name_len = sizeof(client_name);
pthread_t newthread;

sever_sock = startup(&port); //建立socket通讯,并进行端口监听
printf("httpd running on port %d\n", port);

while(1)
{
client_sock = accept(sever_sock,
(struct sockaddr *)&client_name,
&client_name_len); // 接受客户端请求
if(client_sock == -1)
{
error_die("accept failed");
}
if(pthread_create(&newthread, NULL, accept_request, (void *)&client_sock) != 0) // 创建子线程处理客户端请求
{
perror("pthread_create failed");
}
}

cleanup(sever_sock); // 关闭socket,释放相关资源
printf("httpd stopped\n");
return 0;
}

基础通讯实现 -> startup()

HTTP是一个应用层协议,通过TCP/IP进行传输的。HTTP协议规定,连接请求从客户端发起,服务端提供资源响应。在客户端无请求的情况下,服务端不会主动发送响应。服务端通讯建立过程: socket创建套接字 –> bind绑定套接字 –> listen监听套接字 –> accept等待客户端连接请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int startup(u_short *port)
{
int httpd = 0;
struct sockaddr_in name;

// 创建socket描述符:采用TCP通讯方式,在第二个参数确定的情况下,第三个参数可以传0由函数自动匹配对应协议
httpd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if( httpd == -1 )
{
error_die("socket failed");
}

// 绑定套接字:绑定IP地址和端口号
memset(&name, 0, sizeof(name));
name.sin_family = AF_INET;
name.sin_port = htons(*port); // 指定端口:若端口为0,则自动分配一个端口。将端口转换为网络字节序
name.sin_addr.s_addr = htonl(INADDR_ANY); //IP地址:INADDR_ANY -> 服务器上所有的IP对应端口号都监听
if( bind(httpd,(const struct sockaddr *)&name, sizeof(name) ) < 0 )
{
error_die("bind failed");
}

// 若端口为0,获取自动分配的端口号
if(*port == 0)
{
int namelen = sizeof(name);
if( getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1 ) // 获取套接字信息
{
error_die("getsockname failed");
}
*port = ntohs(name.sin_port); // 获取端口号: 网络字节序转主机字节序
}

// 监听socket
if( listen(httpd, 5) < 0 ) // 监听httpd,等待客户端连接请求,并设置最大可排队连接数为5个
{
error_die("listen failed");
}
return httpd;
}
```

#### 请求处理 -> accept_request() ####
accept_request是这个httpd的主体。通过解析http请求,对应发送资源和响应。http请求报文主要由三部分组成: 报文首部(分请求起始行和可选的请求首部字段)、空行、报文主体。通常并不一定要有报文主体。请求报文中每一行都以回车换行(**CRLF**,即"\r\n")作为结束标志。
``` html
Method URL HTTP_Version<CRLF> // 请求起始行
Header_Name: Header_Value<CRLF> // 请求首部字段,可选
... ...
Header_Name: Header_Value<CRLF>
<CRLF> // 空行,表示报文首部结束
BODY // 报文主体

下文我们用来分析的报头首部是用wireshark抓chrome访问httpd时发出的,只有报文首部,没有报文主体。不同浏览器可能有所差异,具体可用wireshark尝试分析。
TinyHttpd主要是针对请求起始行进行处理。请求起始行由Method、Request-Url和Http版本信息组成,三者通过空格隔开。如下请求起始行中”GET”就是method,表示请求访问服务器的类型,用于告知服务器访问意图。”/“为URL,表示请求访问的资源,也称作Request-URL,”HTTP/1.1”表示http版本信息,用来提示客户端使用的http协议功能。
下面的内容为请求首部字段,是可选的,在accept_request的execute_cgi中,我们只有在处理POST请求时才会去解析这部分的内容,对于GET,我们解析请求起始行后会去清除buf中的这部分数据,避免对后续处理或者下次通讯请求造成影响。

1
2
3
4
5
6
7
8
9
GET / HTTP/1.1                // 请求起始行
Host: 192.168.179.145:5277 // 以下为可选首部字段,格式为Header-Name: Header-Value<CRLF>
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6

解析请求的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
void *accept_request(void *pclient)
{
int client = *(int*)pclient;
char buf[1024];
char method[255] = {0, };
char url[255] = {0, };
char path[255] = {0, };
char *query_string = NULL;
struct stat st;
int i = 0, j = 0, cgi = 0;
unsigned int numofchars = 0;

numofchars = sock_getline(client, buf, sizeof(buf)); // 获取一行请求报文,以LF(\n)作为结尾。
#if DEBUG_ENABLE
printf("recieve : %s", numofchars == 0 ? "NULL\r\n" : buf);
#endif

// 对于http报文来说,第一行即为请求起始行:method url http-version
while( !isspace((int)buf[i]) && (i < sizeof(method) - 1) // 获取请求方法
method[j++] = buf[i++];
method[i] = '\0';

// strcasecmp为忽略大小写,比较字符串是否相同,相同则返回0,否则参数1长度大于参数2时返回正值,反之返回负值。
// TinyHttpd只支持GET和POST两种方法
if( strcasecmp(method, "GET") && strcasecmp(method, "POST") )
{
bad_request(client);
return ;
}

// 检测请求是POST还是GET,若为POST则需要CGI处理,置起对应标志
cgi = strcasecmp(method, "POST") == 0 ? 1 : 0;

//清除多余空格
while( isspace((int)buf[j]) && (j++ < sizeof(buf)) )
;
i = 0;
//获取URL,用于确定访问什么资源
while( !isspace((int)buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf)) )
{
url[i++] = buf[j++];
}
url[i] = '\0';
#if DEBUG_ENABLE
printf("Request-URL: %s\r\n", url);
#endif

/* process the request */
if(cgi == 0) /* method : GET */
{
query_string = url;
// 若GET请求的URL带?,则表明有查询参数,须CGI处理
while( (*query_string != '?') && (*query_string != '\0') )
query_string++;
if (*query_string == '?') /* should be process by CGI */
{
cgi = 1;
*query_string = '\0';
query_string++; //截取查询的字符串
}
}
/*以上为请求起始行的解析过程。*/

// 将URL转化为本地资源路径path
sprintf(path, "htdocs%s", url);

// 如果path为目录则返回首页路径
if(path[strlen(path) - 1] == '/')
{
strcat(path, "index.html");
}
#if DEBUG_ENABLE
printf("request path: %s\r\n", path);
#endif

//检测请求文件是否存在
if(stat(path, &st) == -1)
{
//文件不存在则清除剩余header信息,即可选首部字段部分。
while( (numofchars > 0) && strcmp("\n", buf) )
{
numofchars = sock_getline(client, buf, sizeof(buf));
}
not_found(client); // 向浏览器声明没有相应资源
}
else
{
// 若请求URL为路径,则返回首页
// warning: 这里有一个bug,假设URL为"htdocs/index",本地存在这个目录,
// 但不存在"htdocs/index/index.html"这里会合成之后的路径就是错的
if( (st.st_mode & S_IFMT) == S_IFDIR )
{
strcat(path, "/index.html");
}
// 检测到文件具备可执行权限,当请求文件为可执行程序,则应执行对应程序获取执行结果
if( (st.st_mode & S_IXUSR ) || // 文件所有者具备执行权限
(st.st_mode & S_IXGRP ) || // 用户组具备执行权限
(st.st_mode & S_IXOTH ) ) // 其他用户具备可执行权限
{
cgi = 1;
}

#if DEBUG_ENABLE
printf("cgi[%d]: goto %s\r\n", cgi, cgi == 0 ? "serve_file":"execute_cgi");
#endif

if (cgi == 0)
{
serve_file(client, path); // 请求文件存在且非执行,则发送文件内容
}
else
{
execute_cgi(client, path, method, query_string); // 需执行CGI获取内容的
}
}
close(client); //释放客户端套接字,通讯结束
}

执行CGI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
void execute_cgi( int client, const char *path, const char *method, const char *query_string )
{
char buf[1024]= {'A', 0,};
int cgi_in[2]={0,0}, cgi_out[2] = {0,0}; //声明管道通讯,用于父子进程之间的通讯
unsigned int content_length = -1, numofchars = 1;
char ch = '\0';
pid_t pid = -1;
int i = 0, status;

if (strcasecmp(method, "GET") == 0)
{
//如果是GET方法,则清除剩余http头
while( (numofchars > 0) && strcmp("\n",buf) ) //clean the header
{
sock_getline(client, buf, sizeof(buf));
}
}
else
{
while((numofchars > 0) && strcmp(buf, "\n")) // 解析终止条件:HTTP请求头部解析完
{
buf[14] = '\0'; //strlen("content-length") == 14
// 解析http头请求字段,获取content-length字段值,即实体主体大小
if( 0 == strcasecmp(buf, "content-length") )
{
content_length = atoi(&buf[16]);
}
numofchars = sock_getline(client, buf, sizeof(buf));
}
if(content_length == -1)
{
//如果没有成功解析到,则表明这是一个错误请求
bad_request(client);
return ;
}
}
// 响应报文,返回正确响应码200
send_str(client, "HTTP/1.0 200 OK\r\n"); // 响应报文起始行组成: HTTP-Version Status-Code Reason-Phrase

//pipe操作必须在fork之前,这边子进程才能继承到两组文件描述符,实现父子进程之间的通讯
if( (pipe(cgi_out) < 0) || (pipe(cgi_in) < 0) )
{
//创建管道,fd[0]-->读 fd[1]<--写,创建失败则返回信息给客户端
cannot_execute(client);
return ;
}

if( (pid = fork()) < 0 )
{
cannot_execute(client);
return ;
}

//为方便理解和阅读代码,加的定义
#define DEFINE_STDIN (0)
#define DEFINE_STDOUT (1)
#define DEFINE_STDERR (2)

if(pid == 0)
{
char meth_env[255], query_env[255], length_env[255];
dup2(cgi_out[1], DEFINE_STDOUT); // dup2将系统标准输出定义到cgi_out[1]
close(cgi_out[0]); // 关闭cgi_out[0],避免误操作
dup2(cgi_in[0], DEFINE_STDIN); // 将系统标准输入定义到cgi[0]上
close(cgi_out[1]);

sprintf(meth_env, "REQUEST_METHOD=%s", method); //将请求方法保存在进程所在的环境变量中
putenv(meth_env);

if( strcasecmp(method,"GET") == 0 )
{
sprintf(query_env, "QUERY_STRING=%s", query_string); // GET方法需提供查询的信息
putenv(query_env);
}else{
sprintf(length_env, "CONTENT_LENGTH=%d", content_length); // POST方法提供主题的大小
putenv(length_env);
}
execl(path, path, NULL); // 执行CGI程序,同时继承了子进程的文件描述符
exit(0);
}else{
// 关闭两个不会操作到的pipe,避免误操作
close(cgi_in[0]);
close(cgi_out[1]);

if(strcasecmp(method, "POST") == 0)
{
for(i = 0; i < content_length; i++)
{
recv(client, &ch, 1, 0); // POST方法需要解析报文主体实体,然后发给CGI程序
write(cgi_in[1], &ch, 1);
#if DEBUG_ENABLE
printf("%c", ch);
#endif
}
}
while(read(cgi_out[0], &ch, 1) > 0) // 获取CGI执行结果,并通过Socket返回客户端
{
#if DEBUG_ENABLE
printf("%c", ch);
#endif
send(client, &ch, 1, 0);
}
close(cgi_out[0]);
close(cgi_in[1]);
waitpid(pid, &status, 0); // 等待所有子进程执行完毕
}
}

文件发送实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void cat( int client, FILE *resource )
{
char buf[1024];

fgets( buf, sizeof(buf), resource ); // 读取1024bytes数据
while(!feof(resource)) // 如果文件未EOF则继续读
{
send(client, buf, strlen(buf), 0); // socket传输数据
fgets(buf, sizeof(buf), resource);
}
}

void serve_file( int client, const char *filename )
{
FILE *resource = NULL;
int numofchars = 1;
char buf[1024] = {'A', '\0',};

/* read & discard headers */
while( (numofchars > 0) && strcmp("\n", buf) )
{
numofchars = sock_getline( client, buf, sizeof(buf) ); // 清除请求头。
}

resource = fopen(filename, "r"); // 打开文件读取
if( resource == NULL )
{
not_found(client); // 资源未找到或无法访问
}
else
{
headers(client, filename); // 发送服务器响应报文首部
cat(client, resource); // 发送服务器响应实体主体
}
fclose(resource); // 释放资源
}

相关函数实现

1、获取客户端请求报文的一行内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int sock_getline(int sock, char *buf, unsigned int size)
{
int i = 0;
char ch = '\0';
int n = 0;

if((buf == NULL) && (size == 0) && (sock == -1)) // 参数合法性检查
{
printf("parameter error, please check %s[%d]\n", __func__, __LINE__);
return -1;
}

while( (i < size - 1) && (ch != '\n') ) // \n是行结束标志
{
n = recv(sock, &ch, 1, 0);
if(n > 0)
{
if(ch == '\r')
{
n = recv(sock, &ch, 1, MSG_PEEK); // MSG_PEEK可实现下次读到的,仍是此次读取到的内容
if( (n > 0) && (ch == '\n') ) // 若读取到的\r\n,则此次读取结束,读取到的字符为\n
{
recv(sock, &ch, 1, 0);
}
else
{
ch = '\n'; // 否则设定读取的字符为\n,读取结束
}
}
buf[i] = ch;
i++;
}
else
{
ch = '\n';
}
}
buf[i] = '\0';
return i;
}

2、服务器响应报文实现
为方便代码编写和阅读,我在tinyhttpd的基础上实现了下面这个函数,专门用于发送字符到socket

1
2
3
4
5
6
7
void send_str(int client, const char *str)
{
unsigned int ret = send(client, str, strlen(str), 0);
#if DEBUG_ENABLE
ret == strlen(str) ? 0 : printf("send_str error[ret = 0x%02x].\r\n", ret);
#endif
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/*发送文件前的响应头*/
void headers( int client, const char *filename )
{
(void)filename;
send_str(client, "HTTP/1.0 200 OK\r\n"); // 2**表示执行成功,200表示请求被正常处理
send_str(client, SERVER_STRING);
send_str(client, "Content-Type: text/html\r\n"); // 发送资源的MIME为text/html,即文本类型
send_str(client, "\r\n");
}

/* 未找到文件或无法访问文件的响应报文 */
void not_found(int client)
{
#if DEBUG_ENABLE
printf("not found.\r\n");
#endif
// 4**的状态码表明错误是客户端引发的
send_str(client, "HTTP/1.0 404 NOT FOUND\r\n"); //404表示请求的资源不存在或服务器不提供此资源访问
send_str(client, SERVER_STRING);
send_str(client, "Content-Type: text/html\r\n");
send_str(client, "\r\n");
send_str(client, "<HTML><TITLE>NOT FOUND</TITLE>" // 发送一个简单页面用于提示
"<BODY><P> the sever couldn't fullfill"
"your request because the resource specified"
"is unavailable or nonexistence."
"</BODY></HTML>\r\n");
}

/* 错误请求响应报文*/
void bad_request(int client)
{
#if DEBUG_ENABLE
printf("bad request.\r\n");
#endif
// 服务器不支持对应的方法或者报文语法时,会发出错误请求报文
send_str(client, "HTTP/1.0 400 BAD REQUEST\r\n"); // 400表示请求错误或者请求的报文中存在语法错误
send_str(client, "Content-Type: text/html\r\n");
send_str(client, "\r\n");
send_str(client, "<P> Your browse sent a bad request,"
"such as a POST without a Content-Length.\r\n");
}

/*服务器内部异常响应报文*/
void cannot_execute(int client)
{
#if DEBUG_ENABLE
printf("can not execute.\r\n");
#endif
send_str(client, "HTTP/1.0 500 Internal Server Error\r\n"); // 5**为服务器错误,500表示服务器在执行请求时发生错误
send_str(client, "Content-Type: text/html\r\n");
send_str(client, "\r\n");
send_str(client, "<p>error prohibited CGI execution.\r\n");
}